Skip to content

TST-40: OAuth and authentication edge case tests#737

Merged
Chris0Jeky merged 4 commits intomainfrom
test/707-oauth-auth-edge-cases
Apr 3, 2026
Merged

TST-40: OAuth and authentication edge case tests#737
Chris0Jeky merged 4 commits intomainfrom
test/707-oauth-auth-edge-cases

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Adds 44 integration tests covering authentication edge cases across the service and API layers
  • Tests JWT lifecycle: expired tokens, wrong signing key, wrong issuer/audience, future nbf, missing/malformed claims, deleted/inactive user token validation
  • Tests OAuth code exchange: empty code, invalid code, replay prevention (single-use enforcement), expired codes
  • Tests open redirect prevention on GitHub login returnUrl
  • Tests login edge cases: blank credentials, inactive user, wrong password, successful login after failure, concurrent JWT uniqueness (different JTIs)
  • Tests registration edge cases: duplicate email (409), blank fields, username too short/long, invalid email
  • Tests password change: wrong current password, nonexistent user, successful hash update, documents that password change does NOT invalidate existing tokens
  • Tests TokenValidationMiddleware: account deletion during session (401), token invalidation timestamp, re-auth after invalidation, missing userId passthrough
  • Documents a latent Substring bug in username collision overflow (GUID fallback fails for short usernames < 18 chars)

Closes #707

Test plan

  • All 44 new tests pass
  • Full backend suite (2398 tests) passes with zero failures
  • Tests prove security properties: token rejection, replay prevention, open redirect blocking
  • Documents current behavior gaps (password change not invalidating tokens, Substring overflow)

…ation, and external login

Tests cover: blank credentials, inactive user login, wrong password, concurrent JWT uniqueness,
duplicate email registration, username length validation, invalid email format, malformed/expired/
wrong-key/wrong-issuer/wrong-audience token rejection, missing/non-GUID sub claim rejection,
deleted/inactive user token validation, password change behavior, and external login edge cases
including deleted linked account and username collision overflow (documents latent Substring bug).

Linked to #707.
…uth code exchange

Tests cover: empty/invalid/replayed/expired OAuth authorization codes, open redirect prevention,
GitHub login when unconfigured, null/empty login body, account deletion during active session
(TokenValidationMiddleware 401), token invalidation timestamp enforcement, re-authentication
after invalidation, and missing userId claims passthrough.

Linked to #707.
Copilot AI review requested due to automatic review settings April 3, 2026 19:55
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Self-Review

Security properties verified

Property Test Verdict
OAuth code single-use (replay prevention) ExchangeCode_ShouldPreventReplay_SecondUseOfSameCode Proven: TryRemove consumes code atomically
Expired OAuth code rejection ExchangeCode_ShouldReturn401_WhenCodeHasExpired Proven
Open redirect prevention GitHubLogin_ShouldReturn400_WhenReturnUrlIsExternal Proven via Url.IsLocalUrl mock
Expired JWT rejection ValidateTokenAsync_ShouldRejectExpiredToken Proven
Wrong signing key rejection ValidateTokenAsync_ShouldRejectTokenSignedWithWrongKey Proven
Wrong issuer/audience rejection Two separate tests Proven
Future nbf rejection ValidateTokenAsync_ShouldRejectTokenWithFutureNbf Proven
Missing/malformed sub claim rejection Two separate tests Proven
Deleted user token rejection ValidateTokenAsync_ShouldRejectTokenForDeletedUser + middleware test Proven at both service and middleware layers
Inactive user token rejection ValidateTokenAsync_ShouldRejectTokenForInactiveUser + middleware test Proven
Token invalidation timestamp enforcement TokenValidationMiddleware_ShouldReturn401_WhenTokenIssuedBeforeInvalidation Proven
Duplicate email registration (409) RegisterAsync_ShouldReturnConflict_WhenDuplicateEmail Proven
Concurrent JWTs have unique IDs LoginAsync_ConcurrentLogins_ShouldReturnDifferentJtis Proven

Bug found during testing

AuthenticationService.ExternalLoginAsync has a latent Substring(0, 50) overflow when the GUID-based username fallback produces a string shorter than 50 characters (any original username < 18 chars). The generic catch (Exception) swallows the ArgumentOutOfRangeException, returning UnexpectedError. Test ExternalLoginAsync_ShouldReturnError_WhenAllUsernameVariantsTaken_ShortUsername documents this. Consider fixing with [..Math.Min(50, str.Length)].

Gaps acknowledged (not bypasses)

  1. GitHubCallback endpoint: Not directly tested because it requires the full ASP.NET authentication middleware stack (HttpContext.AuthenticateAsync("GitHub")). Would need WebApplicationFactory with a mocked GitHub OAuth handler.
  2. Rate limiting interaction: Auth rate limiting (AuthPerIp) is not tested here; would need integration-level tests with the rate limiting middleware configured.
  3. Password change does not invalidate tokens: Documented as current behavior in PasswordChange_DoesNotInvalidateExistingTokens. This is a design choice, not a bug, but worth considering for future hardening.
  4. Reflection-based test setup: InjectAuthCode uses reflection to access the static _authCodes dictionary. Fragile if the field is renamed, but acceptable for testing static state without a full HTTP pipeline.

No bypasses found

All tested rejection paths return the correct HTTP status codes with structured ApiErrorResponse payloads. No path allows an invalid/expired/revoked token to reach downstream handlers.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive suite of edge-case integration and unit tests for the authentication system, specifically targeting the AuthController, AuthenticationService, and security middleware. These tests cover OAuth code exchange, JWT lifecycle management, and session invalidation scenarios. Review feedback identifies a security vulnerability where password changes fail to invalidate existing sessions and a bug in the external login logic that could cause an ArgumentOutOfRangeException during username generation.

Comment on lines +503 to +527
[Fact]
public async Task PasswordChange_DoesNotInvalidateExistingTokens()
{
// Current behavior: password change does NOT set TokenInvalidatedAt.
// Existing JWTs remain valid until natural expiry. Document this behavior.
var oldPassword = "oldPassword1";
var newPassword = "newPassword1";
var user = new User("testuser", "test@example.com", BCrypt.Net.BCrypt.HashPassword(oldPassword));
var service = CreateService();

_userRepoMock.Setup(r => r.GetByUsernameAsync("testuser", default)).ReturnsAsync(user);
_userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user);

// Get a token before password change
var loginResult = await service.LoginAsync(new LoginDto("testuser", oldPassword));
loginResult.IsSuccess.Should().BeTrue();

// Change password
var changeResult = await service.ChangePasswordAsync(user.Id, oldPassword, newPassword);
changeResult.IsSuccess.Should().BeTrue();

// Validate old token — it should still work (TokenInvalidatedAt not set)
var validateResult = await service.ValidateTokenAsync(loginResult.Value.Token);
validateResult.IsSuccess.Should().BeTrue();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

For security reasons, changing a user's password should invalidate all of their existing sessions/tokens. The current implementation of ChangePasswordAsync in AuthenticationService does not do this, and this test documents that behavior.

This is a significant security vulnerability. An attacker who has compromised a user's token can maintain access even after the user changes their password.

I recommend updating ChangePasswordAsync to call user.InvalidateTokens(). Consequently, this test should be replaced with one that asserts user.TokenInvalidatedAt is set after a successful password change. The actual token rejection is handled by TokenValidationMiddleware and is already covered in other tests.

    [Fact]
    public async Task ChangePasswordAsync_ShouldInvalidateExistingTokens()
    {
        var oldPassword = "oldPassword1";
        var newPassword = "newPassword1";
        var user = new User("testuser", "test@example.com", BCrypt.Net.BCrypt.HashPassword(oldPassword));
        var service = CreateService();
        _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user);

        // Act
        var changeResult = await service.ChangePasswordAsync(user.Id, oldPassword, newPassword);

        // Assert
        changeResult.IsSuccess.Should().BeTrue();
        user.TokenInvalidatedAt.Should().NotBeNull("password change should invalidate existing tokens");
    }

Comment on lines +554 to +587
[Fact]
public async Task ExternalLoginAsync_ShouldReturnError_WhenAllUsernameVariantsTaken_ShortUsername()
{
// Known defect: when > 100 username variants are taken and the original
// username is short (< 18 chars), the GUID fallback generates a string
// shorter than 50 characters, causing Substring(0, 50) to throw
// ArgumentOutOfRangeException. The generic catch returns UnexpectedError.
// This documents the current behavior for future fix consideration.
var service = CreateService();

_externalLoginRepoMock
.Setup(r => r.GetByProviderAsync("GitHub", "99999", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExternalLogin?)null);

_userRepoMock.Setup(r => r.GetByEmailAsync("unique@example.com", It.IsAny<CancellationToken>())).ReturnsAsync((User?)null);

// Every username variant returns a user (simulating all taken)
var takenUser = new User("taken", "taken@test.com", BCrypt.Net.BCrypt.HashPassword("p"));
_userRepoMock.Setup(r => r.GetByUsernameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(takenUser);

_userRepoMock.Setup(r => r.AddAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((User u, CancellationToken _) => u);

_externalLoginRepoMock.Setup(r => r.AddAsync(It.IsAny<ExternalLogin>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ExternalLogin l, CancellationToken _) => l);

var dto = new ExternalLoginDto("GitHub", "99999", "popular", "unique@example.com");
var result = await service.ExternalLoginAsync(dto);

// Current behavior: returns UnexpectedError due to Substring overflow
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ErrorCodes.UnexpectedError);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test correctly identifies a bug in ExternalLoginAsync where Substring(0, 50) can throw an ArgumentOutOfRangeException for short usernames, but it only asserts the resulting UnexpectedError. Instead of just documenting this defect, the underlying bug in AuthenticationService should be fixed.

The fix is to ensure the string is long enough before calling Substring. For example:

// in AuthenticationService.ExternalLoginAsync
var fullUsername = $"{normalizedUsername}-{Guid.NewGuid():N}";
candidateUsername = fullUsername.Length > 50 ? fullUsername.Substring(0, 50) : fullUsername;

After fixing the service, this test should be updated to assert that the user creation succeeds in this edge case, rather than asserting that an error is returned.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds new test coverage for authentication and OAuth edge cases across the application service layer (AuthenticationService) and API layer (AuthController + TokenValidationMiddleware), aligning with issue #707’s goal of hardening security boundaries via regression tests.

Changes:

  • Added a new AuthenticationServiceEdgeCaseTests suite covering login, registration, token validation, password change, and external login edge cases.
  • Added a new AuthControllerEdgeCaseTests suite covering OAuth exchange replay/expiry, GitHub login returnUrl validation, and token invalidation/deleted-user middleware behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
backend/tests/Taskdeck.Application.Tests/Services/AuthenticationServiceEdgeCaseTests.cs Adds edge-case tests for AuthenticationService login/registration/JWT validation/password/external login behaviors.
backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs Adds edge-case tests for AuthController OAuth exchange + GitHub login, plus TokenValidationMiddleware invalidation/deleted user handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

_externalLoginRepoMock = new Mock<IExternalLoginRepository>();

_unitOfWorkMock.Setup(u => u.Users).Returns(_userRepoMock.Object);
_unitOfWorkMock.Setup(u => u.ExternalLogins).Returns(_externalLoginRepoMock.Object);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test fixture doesn't set default Moq returns for async methods that the SUT awaits (e.g., IUnitOfWork.SaveChangesAsync and IUserRepository.GetByEmailAsync). Moq returns null for unconfigured Task-returning members, so tests that hit these paths can throw at await-time and be reported as UnexpectedError instead of the asserted outcomes. Consider adding baseline setups here (e.g., SaveChangesAsync => ReturnsAsync(1), GetByEmailAsync(any) => ReturnsAsync((User?)null)) and override per-test when needed.

Suggested change
_unitOfWorkMock.Setup(u => u.ExternalLogins).Returns(_externalLoginRepoMock.Object);
_unitOfWorkMock.Setup(u => u.ExternalLogins).Returns(_externalLoginRepoMock.Object);
// Baseline async setups for awaited members so loose mocks do not return null Tasks.
// Individual tests can override these when they need specific behavior.
_unitOfWorkMock.Setup(u => u.SaveChangesAsync()).ReturnsAsync(1);
_userRepoMock.Setup(r => r.GetByEmailAsync(It.IsAny<string>())).ReturnsAsync((User?)null);

Copilot uses AI. Check for mistakes.
var user = new User("testuser", "test@example.com", BCrypt.Net.BCrypt.HashPassword(password));
var service = CreateService();

_userRepoMock.Setup(r => r.GetByUsernameAsync("testuser", default)).ReturnsAsync(user);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test configures GetByUsernameAsync but not GetByEmailAsync. AuthenticationService.ResolveLoginCandidatesAsync awaits both repository calls; leaving GetByEmailAsync unconfigured causes Moq to return null (Task<User?>), leading to a NullReferenceException when awaited and a failing test. Either set GetByEmailAsync to return null in this test or provide a default setup in the fixture constructor.

Suggested change
_userRepoMock.Setup(r => r.GetByUsernameAsync("testuser", default)).ReturnsAsync(user);
_userRepoMock.Setup(r => r.GetByUsernameAsync("testuser", default)).ReturnsAsync(user);
_userRepoMock.Setup(r => r.GetByEmailAsync("testuser", default)).ReturnsAsync((User?)null);

Copilot uses AI. Check for mistakes.
var code = "test-replay-code";
var authResult = new AuthResultDto("fake-token", new UserDto(
Guid.NewGuid(), "user", "user@test.com",
Domain.Enums.UserRole.Editor, true,
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines reference Domain.Enums.UserRole, but this test file doesn't define a Domain alias/namespace (the enum is Taskdeck.Domain.Enums.UserRole). As written, the file won't compile. Update the references to the correct namespace (or add an explicit alias) so the tests build.

Suggested change
Domain.Enums.UserRole.Editor, true,
Taskdeck.Domain.Enums.UserRole.Editor, true,

Copilot uses AI. Check for mistakes.

private static void SetUserId(User user, Guid userId)
{
var idProperty = typeof(Domain.Common.Entity).GetProperty("Id");
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeof(Domain.Common.Entity) won't compile here for the same reason as the Domain.Enums references above (no Domain namespace/alias in this file). Use Taskdeck.Domain.Common.Entity (or Taskdeck.Domain.Common.Entity via a using/alias) when reflecting to set the Id.

Suggested change
var idProperty = typeof(Domain.Common.Entity).GetProperty("Id");
var idProperty = typeof(Taskdeck.Domain.Common.Entity).GetProperty("Id");

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +26
/// Edge-case integration tests for AuthController, TokenValidationMiddleware,
/// and ActiveUserValidationMiddleware — verifying security properties around
/// OAuth flows, JWT lifecycle, and session invalidation.
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File header comment says these tests cover ActiveUserValidationMiddleware, but the test class only exercises AuthController and TokenValidationMiddleware. Update the summary to match what's actually covered (or add the missing middleware coverage) to avoid misleading documentation.

Suggested change
/// Edge-case integration tests for AuthController, TokenValidationMiddleware,
/// and ActiveUserValidationMiddleware — verifying security properties around
/// OAuth flows, JWT lifecycle, and session invalidation.
/// Edge-case integration tests for AuthController and TokenValidationMiddleware,
/// verifying security properties around OAuth flows, JWT lifecycle,
/// and session invalidation.

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +104
// The expired code is removed by TryRemove, so the response is 401 "invalid or expired"
result.Should().BeOfType<UnauthorizedObjectResult>();
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment about the expired code response is inaccurate: AuthController.ExchangeCode first TryRemoves the entry and then returns 401 with the specific "Code has expired" message when the expiry check fails. Consider updating the comment to reflect the actual behavior so the test remains self-explanatory.

Suggested change
// The expired code is removed by TryRemove, so the response is 401 "invalid or expired"
result.Should().BeOfType<UnauthorizedObjectResult>();
// ExchangeCode removes the stored code first, but still returns a 401 with the specific expired-code error.
var unauthorized = result.Should().BeOfType<UnauthorizedObjectResult>().Subject;
var error = unauthorized.Value.Should().BeOfType<ApiErrorResponse>().Subject;
error.ErrorCode.Should().Be(ErrorCodes.AuthenticationFailed);
error.Message.Should().Be("Code has expired");

Copilot uses AI. Check for mistakes.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review (Round 2)

Real issues to fix

1. Substring bug should be fixed, not just documented
AuthenticationService.cs:182$"{normalizedUsername}-{Guid.NewGuid():N}".Substring(0, 50) throws ArgumentOutOfRangeException when normalizedUsername is shorter than 17 characters (since GUID :N is 32 chars + 1 dash = 33, total < 50). Both Gemini and Copilot flagged this. The test at AuthenticationServiceEdgeCaseTests.cs:555-587 documents it but does not fix it. The fix is a one-liner: [..Math.Min(50, str.Length)]. Since this PR already has the test proving the bug, it should ship the fix too.

2. Expired code test comment is misleading (AuthControllerEdgeCaseTests.cs:103)
Comment says "The expired code is removed by TryRemove, so the response is 401 'invalid or expired'" — but this is wrong. TryRemove succeeds (the code was in the dictionary), then the expiry check on AuthController.cs:217 fires and returns "Code has expired". The test passes but the comment describes the wrong code path. The assertion should verify the specific error message to prove which branch was taken.

3. "Concurrent" JTI test is sequential (AuthenticationServiceEdgeCaseTests.cs:500-519)
LoginAsync_ConcurrentLogins_ShouldReturnDifferentJtis calls LoginAsync twice sequentially with await. This proves uniqueness of JTIs across sequential calls, but does not test actual concurrency (e.g., Task.WhenAll). The test name overpromises. Either rename to SequentialLogins_ShouldReturnDifferentJtis or make it actually concurrent.

4. Class docstring mentions ActiveUserValidationMiddleware but tests do not cover it (AuthControllerEdgeCaseTests.cs:25)
The summary says these tests cover ActiveUserValidationMiddleware, but only TokenValidationMiddleware is exercised. This is misleading.

Observations (not blocking, but worth noting)

5. ValidateTokenAsync does not constrain accepted algorithms (AuthenticationService.cs:247-257)
TokenValidationParameters does not set ValidAlgorithms. While not exploitable with a symmetric key (the library enforces key-type compatibility), explicitly setting ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 } would be defense-in-depth. No test covers algorithm confusion. Consider adding one in a follow-up.

6. Password change not invalidating tokens — documented at AuthenticationServiceEdgeCaseTests.cs:503-527
This is a real security gap (Gemini also flagged it as high priority). The test correctly documents current behavior, but fixing this should be tracked as a separate issue if not already.

7. Reflection fragility (AuthControllerEdgeCaseTests.cs:331-339)
InjectAuthCode and SetUserId use reflection to access private/internal state. This is acceptable for testing static state without a full HTTP pipeline, but any rename of _authCodes or Entity.Id will silently break these tests at runtime (not compile-time). Consider adding a comment noting this coupling.

Bot findings triage

Verdict

Fix items #1-4 before merge. Items #5-7 can be follow-up issues.

The GUID-based username fallback used Substring(0, 50) which throws
ArgumentOutOfRangeException when the original username is shorter than
17 characters. Use Math.Min(50, length) to safely truncate.
- Update Substring overflow test to assert success after bug fix
- Fix misleading comment on expired code test, add message assertion
- Rename ConcurrentLogins test to SequentialLogins (tests are sequential)
- Remove ActiveUserValidationMiddleware from class docstring (not tested)
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Review Round 2 -- Fixes Pushed

Two commits pushed to address the 4 findings from the adversarial review:

c99ec9f -- Fix Substring overflow in ExternalLoginAsync GUID fallback

  • Production bug fix: AuthenticationService.cs:182 -- changed Substring(0, 50) to [..Math.Min(50, guidFallback.Length)]
  • This was a latent ArgumentOutOfRangeException for any external login username shorter than 17 characters when all 100+ suffix variants are taken

35d6c7b -- Fix review findings in auth edge case tests

  1. Updated ExternalLoginAsync_ShouldSucceedWithGuidFallback_WhenAllUsernameVariantsTaken_ShortUsername to assert success (was asserting UnexpectedError for the now-fixed bug)
  2. Fixed misleading comment on ExchangeCode_ShouldReturn401_WhenCodeHasExpired and added message-level assertion to prove the correct code path
  3. Renamed ConcurrentLogins to SequentialLogins (the test is not concurrent)
  4. Removed ActiveUserValidationMiddleware from class docstring (not actually tested)

Verification

  • Build: 0 errors, same 4 pre-existing warnings
  • All 44 auth edge case tests pass (31 Application + 13 Api)

Remaining follow-ups (not blocking this PR)

  • Password change should invalidate existing tokens (separate issue)
  • ValidateTokenAsync should constrain ValidAlgorithms to HmacSha256 (defense-in-depth)

Bot triage

  • Copilot "won't compile" findings on Domain.Enums.UserRole and Domain.Common.Entity: false positives -- code compiles and tests pass
  • Gemini Substring and password-invalidation findings: valid -- Substring fixed here, password-invalidation is a design decision for a separate issue

@Chris0Jeky Chris0Jeky merged commit 7e79b1a into main Apr 3, 2026
23 checks passed
@Chris0Jeky Chris0Jeky deleted the test/707-oauth-auth-edge-cases branch April 3, 2026 23:59
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

TST-40: OAuth and authentication edge case integration tests

2 participants